Houdini Ocean To Unity

Posted by FlowingCrescent on 2023-02-06
Estimated Reading Time 9 Minutes
Words 2.3k In Total
Viewed Times

前言

许久没能给blog清灰,不知不觉已经到了2023年,虽说依然可以用“做的东西大多是项目的,无法公开”来给自己开脱,但多少还是有些惭愧的。
近期我感觉到如果只是学习Shader相关的内容,作为TA的话技能树就显得有些窄了,因此又重新拾起大学时期曾经学过一点的Houdini,开始做一些更加实用的效果与功能。而这一次我的学习目标是“学习Houdini中烘焙贴图的方法”,之前曾看到过将Houdini中海洋作为序列帧烘焙到引擎中的方案,这次便是要着手亲自实现。

*使用Houdini版本为19.0.383

烘焙波形

Houdini中有着强大的Ocean系列节点,也被广泛用于影视项目中。
将Grid与Ocean Spectrum输入Oceane Valuate即可生成比较真实的波形(注意Grid的大小要与Ocean Spectrum中的匹配,顶点数也要足够,这里为了之后烘焙,便设置为256x256行列)
image.png
那么为了将每个顶点的位移存储到tga或png格式的贴图中,我们要将其的范围映射到[0, 1],那么就该写vex了(其实用Attribute Remap节点大概也可以,但可能是因为我终究是程序出身,还是更喜欢自己写代码控制,有一种莫名的安心感233)
对vex不了解的读者可以阅读学习Joy of Vex
代码比较简单,只是将每个顶点的位移记录为一个名为displace的Attribute。
image.png
观察过计算出的Attribute后发现其值大多不大于2且不小于-2,因此先设置原值的映射范围为[-2, 2],当然根据波形的设置这个值可能会不一样,也当然被映射的范围越大,存储的精度便越低。
然后直接使用Labs Maps Baker对displace进行烘焙,因为我们之后要将其存储为Flipbook,因此要计算一下每一张序列帧的大小,我这里打算最后成图为2048x2048分辨率,存储8x8共64帧(注意也要将Ocean Spectrum中的Loop Period设置为64/24(fps)即2.666666),每帧 256x256分辨率,因此设置如下:
image.png
按下Render后烘焙结果:
image.png

然后切换到Img层级,新建img,在img中新建File节点读取序列帧后连接Mosaic节点(注意,只有在下面的timeline为第一帧时才能预览到Flipbook,谜之设计),即可创建Flipbook,直接在Composite View这边右键Save Frame即可。
image.png
image.png

那么导出Plane的模型,该转到Unity中进行波形的还原了
image.png

还原波形

到了Unity中将DisplaceMap的srgb勾选取消。然后给海面Plane写一个定制的Shader
在Shader中可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
half2 FlipBook(half2 uv, uint horizontalAmount, uint verticalAmount, uint num)
{
num %= (horizontalAmount * verticalAmount);
int row = num / horizontalAmount;
int column = num - row * horizontalAmount;

half2 newUv = frac(uv) + half2(column, verticalAmount - row - 1);
newUv.x /= horizontalAmount;
newUv.y /= verticalAmount;
return newUv;
}


v2f vert(a2v v) {
v2f o;

//计算当前序列帧与下一个序列帧的UV,对采样结果进行插值以平滑动画
float2 currentUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed);
float2 nextUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed + 1);

float3 currentDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, currentUV , 0).xyz;
float3 nextDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, nextUV , 0).xyz;

currentDisplace = currentDisplace * 4 - 2;//to [-2, 2]
nextDisplace = nextDisplace * 4 - 2;
float3 displace = lerp(currentDisplace, nextDisplace, frac(_Time.y * _Speed));
v.positionOS.xyz += displace;

VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = positionInputs.positionCS;
o.positionWS = positionInputs.positionWS;


//......
}

可见是一个非常明了的用uv采样Flipbook然后直接将值应用的没什么特别操作的代码。

然后就发现了问题:
image.png
我这波形怎么这么圆呢?
这个时候我百思不得其解,偶然之间把它翻转过来看了下,发现“背面”的波形好像是“对”的:
image.png
其实这个问题是因为“uv反了”,我们在代码中加一个简单的uv旋转,使其旋转180°:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
half2 RotateByCenter(half2 uv, half2 center, float rotateAngle)
{
half2 transUV = uv - center;
rotateAngle = rotateAngle / 180 * 3.1415926;

transUV = half2(transUV.x * cos(rotateAngle) - transUV.y * sin(rotateAngle),
transUV.x * sin(rotateAngle) + transUV.y * cos(rotateAngle));

return transUV + center;
}

v2f vert(a2v v) {
v2f o;
v.uv = RotateByCenter(v.uv, half2(0.5, 0.5), 180);

//.....
}

image.png
即可获得正确的波形。
为什么是这样呢,我们先把问题简化,假设uv为一维而偏移方向为二维,如果uv的值“反转”,情况就会是这样:
image.png

那么明显可见,这种情况下肉眼所见的波形肯定是不同的,而将平面翻转过来它的背面波形看上去似是而非也很好理解。
至于为什么uv会反,大概是因为Houdini的这个Labs Maps Baker似乎并不依赖于uv进行烘焙,就算不进行展uv,一样能够进行相同的烘焙。大概是直接取用了坐标来转换成uv?然后用节点展的uv恰好与坐标转换出的uv“相反”了。

那么当我想多复制几个海面,让海面更辽阔时,又发现它们之间并不会完全相连,它们之间会出现这种缝隙
image.png
但其实每一帧位移图都是四方连续的,这种情况的发生是由于序列帧本身储存为贴图时并没有左右连续(因为是Flipbook),反倒是与另一帧进行了双线性插值,导致出现问题。
因此在采样前我们要对uv进行一点点偏移,仅偏移半个像素大小,使每个顶点uv移到所采样的贴图的像素中心即可即可。

1
2
3
4
5
6
v2f vert(a2v v) {
v2f o;
v.uv = RotateByCenter(v.uv, half2(0.5, 0.5), 180);
v.uv += _DisplaceMap_TexelSize.xy * _RowAndColumn.x * 0.5;
//......
}

这样便能消除接缝
image.png

法线与切线

那么下一步就是让这个海能够正常渲染了,为此我们还需要它的法线,如果要在此基础上再加一个Detail Normal的话则还需要切线,让我们回到Houdini。
在演算完波形后计算Point法线以及切线,并将它们映射到[0, 1]。
要注意切线必须是Mikkt算法,以求与Unity相同。
image.png
image.png
image.png
image.png

烘焙与Displace相同,同样是作为Attribute烘焙
image.png

序列帧烘出来是这样
image.png

总之也跟之前一样生成Flipbook
image.png

记得在Unity中同样将它们的SRGB勾选去掉。
因此在顶点着色器中可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
v2f vert(a2v v) {
v2f o;
float2 posToUV = v.positionOS.xz / _PlaneScale + 0.5 + _NormalMap_TexelSize.xy * _RowAndColumn.x;
v.uv = RotateByCenter(v.uv, half2(0.5, 0.5), 180);
v.uv += _DisplaceMap_TexelSize.xy * _RowAndColumn.x * 0.5;

float2 currentUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed);
float2 nextUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed + 1);

float2 useCurrentPosUV = currentUV;
float2 useNextPosUV = nextUV;

float3 currentDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, useCurrentPosUV , 0).xyz;
float3 nextDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, useNextPosUV , 0).xyz;


currentDisplace = currentDisplace * 4 - 2;//to [-2, 2]
nextDisplace = nextDisplace * 4 - 2;
float3 displace = lerp(currentDisplace, nextDisplace, frac(_Time.y * _Speed));
v.positionOS.xyz += displace;

VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = positionInputs.positionCS;
o.positionWS = positionInputs.positionWS;


float3 currentNormalOS = SAMPLE_TEXTURE2D_LOD(_NormalMap, sampler_NormalMap, useCurrentPosUV, 0).xyz * 2 - 1;
float3 nextNormalOS = SAMPLE_TEXTURE2D_LOD(_NormalMap, sampler_NormalMap, useNextPosUV, 0) * 2 - 1;
float3 normalOS= lerp(currentNormalOS, nextNormalOS, frac(_Time.y * _Speed));
normalOS = normalize(normalOS);

float3 currentTangnetOS = SAMPLE_TEXTURE2D_LOD(_TangentMap, sampler_TangentMap, useCurrentPosUV, 0).xyz * 2 - 1;
float3 nextTangentOS = SAMPLE_TEXTURE2D_LOD(_TangentMap, sampler_TangentMap, useNextPosUV, 0) * 2 - 1;
float3 tangentOS= lerp(currentTangnetOS, nextTangentOS, frac(_Time.y * _Speed));
tangentOS = normalize(tangentOS);


VertexNormalInputs normalInputs = GetVertexNormalInputs(normalOS, half4(tangentOS, 1));
o.normalWS = normalInputs.normalWS;
o.tangentWS = normalInputs.tangentWS;
o.uv = v.uv;
return o;
}

在片元着色器中简单地算一个着色先:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
half4 frag(v2f i) : SV_Target {

float3 detailNormal = UnpackNormalScale(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, TRANSFORM_TEX(i.uv, _DetailNormalMap) + _Time.y * _DetailNormalMap_ST.zw * _FlowSpeed), _DetailNormalScale);

float3x3 TBN = CreateTangentToWorld(normalize(i.normalWS), normalize(i.tangentWS.xyz), -1);
float3 detailNormalWS = normalize(TransformTangentToWorld(detailNormal, TBN));

float3 normalWS = detailNormalWS;

float lambert = dot(normalWS, normalize(GetMainLight().direction));
float3 diffuse = lerp(_DarkColor, _BrightColor, lambert * 0.5 + 0.5);

float3 viewDirWS = normalize(GetWorldSpaceViewDir(i.positionWS));
float3 halfDir = normalize(viewDirWS + GetMainLight().direction);
float specular = pow(saturate(dot(halfDir, normalWS)), 1000);

return diffuse.rgbr + specular * 5;
}

image.png
粗略的效果大概是这样,接下来就是着色算法的表演时间了,VAT相关的实现大概就到这里。

那么修改亿点点着色算法:
image.png
我们就获得了一片美丽的海洋。
由于着色算法并不是本文重点,因此就不展开了,不然又要长篇大论一番。

总结

这个方案在各个海洋方案中大概也算得上是相对亲民的,美术也比较轻松(不需要调波形),如果是直接在顶点着色器中计算波形,那美术恐怕会面对对他们来说如鬼画符般的“振幅”“波长”“频率”等参数,这对美术而言可以说是无法接受的。
如果要说有什么优化空间,大概是法线和切线能够存储在同一贴图中,因为被标准化后的三维向量能够通过一些算法压缩到二维,且有较高的准确率。那么便可以实现法线信息存储在RG通道,而切线信息存储在BA通道,可以节省一次贴图采样的开销,还是不错的。
向量压缩算法可参考:https://aras-p.info/texts/CompactNormalStorage.html#method04spheremap
以及距离较远时可以直接切换LOD到顶点较少(不需要Displace)只有法线贴图的海面。

那么本文就到此结束,感谢阅读,若你能从此文中有所收获,便再好不过了。


感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。